import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/services.dart';

import 'constants.dart' as Constants;

final SEQRET_DECODER = '=ACMGRSVTWYHKDBN'.split('').map((String s) => s.codeUnitAt(0)).toList();
final CIGAR_DECODER = 'MIDNSHP=X???????'.split('');

/// Class of each BAM record returned by this API.
class BamRecord {
  Map data;
  var bytes;
  var flags;
  num _id;
  num _refID;
  num _tagOffset;
  List<String> _tagList = [];
  bool _allTagsParsed = false;

  ByteData _byteData;

  BamRecord({
    Map bytes,
    int fileOffset,
  }) {
    this.data = {};
    this.bytes = {
      'start': bytes['start'],
      'end': bytes['end'],
      'byteArray': bytes['byteArray'],
    };
    _byteData = ByteData.view(Uint8List.fromList(bytes['byteArray']).buffer);
    this._id = fileOffset;
    int start = bytes['start'];
    this._refID = _byteData.getInt32(start + 4, Endian.little);
    this.data['start'] = _byteData.getInt32(start + 8, Endian.little);
    this.flags = (_byteData.getInt32(start + 16, Endian.little) & 0xffff0000) >> 16;
  }

  Function operator [](key) {
    switch (key) {
      case 'children':
        return children;
      case 'name':
        return name;
      case 'seq_length':
        return seq_length;
      case '_bin_mq_nl':
        return _bin_mq_nl;
      case '_flag_nc':
        return _flag_nc;
      case '_next_refid':
        return _next_refid;
      case '_next_pos':
        return _next_pos;
      case 'template_length':
        return template_length;
      case '_get_seq':
        return _get_seq;
      case 'cigar':
        return cigar;
      case '_read_name':
        return _read_name;
      case 'mq':
        return mq;
      case 'length_on_ref':
        return length_on_ref;
      case '_n_cigar_op':
        return _n_cigar_op;
      case '_l_read_name':
        return _l_read_name;
      case '_seq_bytes':
        return _seq_bytes;
    }
    return null;
  }

  get(String field) {
    //@ts-ignore
    if (this[field] != null) {
      //@ts-ignore
      if (this.data[field] != null) {
        return this.data[field];
      }
      //@ts-ignore
      this.data[field] = this[field]();
      return this.data[field];
    }
    return this._get(field.toLowerCase());
  }

  end() {
    return this.get('start') + (this.get('length_on_ref') ?? this.get('seq_length') ?? null);
  }

  seq_id() {
    return this._refID;
  }

  // same as get(), except requires lower-case arguments.  used
  // internally to save lots of calls to field.toLowerCase()
  _get(String field) {
    if (data.keys.contains(field)) {
      return this.data[field];
    }
    this.data[field] = this._parseTag(field);
    return this.data[field];
  }

  _tags() {
    this._parseAllTags();

    List<String> tags = ['seq'];

    if (!this.isSegmentUnmapped()) {
      tags.addAll([
        'start',
        'end',
        'strand',
        'score',
        'qual',
        'MQ',
        'CIGAR',
        'length_on_ref',
        'template_length',
      ]);
    }
    if (this.isPaired()) {
      tags.addAll(['next_segment_position', 'pair_orientation']);
    }
    tags.addAll(this._tagList ?? []);

    this.data.keys.forEach((k) {
      if (k[0] != '_' && k != 'next_seq_id') {
        tags.add(k);
      }
    });

    Map<String, bool> seen = {};
    tags = tags.where((t) {
      if ((data.keys.contains(t) && this.data[t] == null) || t == 'CG' || t == 'cg') {
        return false;
      }

      var lt = t.toLowerCase();
      bool s = seen[lt];
      seen[lt] = true;
      return !s;
    });

    return tags;
  }

  parent() {
    return null;
  }

  children() {
    return this.get('subfeatures');
  }

  id() {
    return this._id;
  }

  // special parsers
  /**
   * Mapping quality score.
   */
  mq() {
    int mq = (this.get('_bin_mq_nl') & 0xff00) >> 8;
    return mq == 255 ? null : mq;
  }

  score() {
    return this.get('mq');
  }

  qual() {
    if (this.isSegmentUnmapped()) {
      return null;
    }

    var byteArray = this.bytes['byteArray'];
    int p = this.bytes['start'] + 36 + this.get('_l_read_name') + this.get('_n_cigar_op') * 4 + this.get('_seq_bytes');
    var lseq = this.get('seq_length');
    var qseq = List(lseq);
    for (int j = 0; j < lseq; ++j) {
      qseq[j] = byteArray[p + j];
    }
    return qseq.join(' ');
  }

  strand() {
    return this.isReverseComplemented() ? -1 : 1;
  }

  multi_segment_next_segment_strand() {
    if (this.isMateUnmapped()) {
      return null;
    }
    return this.isMateReverseComplemented() ? -1 : 1;
  }

  name() {
    return this.get('_read_name');
  }

  _read_name() {
    var nl = this.get('_l_read_name');
    List<int> __bytes = this.bytes['byteArray'];
    return ascii.decode(__bytes.sublist(
      this.bytes['start'] + 36,
      this.bytes['start'] + 36 + nl - 1,
    ));
    return this.bytes['byteArray'].toString(
          'ascii',
          this.bytes.start + 36,
          this.bytes.start + 36 + nl - 1,
        );
  }

  /**
   * Get the value of a tag, parsing the tags as far as necessary.
   * Only called if we have not already parsed that field.
   */
  _parseTag(String tagName) {
    // if all of the tags have been parsed and we're still being
    // called, we already know that we have no such tag, because
    // it would already have been cached.
    if (this._allTagsParsed) {
      return null;
    }

    var byteArray = this.bytes['byteArray'];
    var p = this._tagOffset ?? this.bytes['start'] + 36 + this.get('_l_read_name') + this.get('_n_cigar_op') * 4 + this.get('_seq_bytes') + this.get('seq_length');

    var blockEnd = this.bytes['end'];
    var lcTag;
    while (p < blockEnd && lcTag != tagName) {
      var tag = String.fromCharCodes(byteArray, p, p + 2);
      lcTag = tag.toLowerCase();
      var type = String.fromCharCode(byteArray[p + 2]);
      p += 3;

      var value;
      switch (type) {
        case 'A':
          value = String.fromCharCode(byteArray[p]);
          p += 1;
          break;
        case 'i':
          value = byteData.getInt32(p, Endian.little);
          p += 4;
          break;
        case 'I':
          value = byteData.getUint32(p, Endian.little);
          p += 4;
          break;
        case 'c':
          value = byteData.getInt8(p);
          p += 1;
          break;
        case 'C':
          value = byteData.getUint8(p);
          p += 1;
          break;
        case 's':
          value = byteData.getInt16(p, Endian.little);
          p += 2;
          break;
        case 'S':
          value = byteData.getUint16(p, Endian.little);
          p += 2;
          break;
        case 'f':
          value = byteData.getFloat32(p, Endian.little);
          p += 4;
          break;
        case 'Z':
        case 'H':
          value = '';
          while (p <= blockEnd) {
            var cc = byteArray[p++];
            if (cc == 0) {
              break;
            } else {
              value += String.fromCharCode(cc);
            }
          }
          break;
        case 'B':
          {
            value = '';
            var cc = byteArray[p++];
            var Btype = String.fromCharCode(cc);
            var limit = byteData.getInt32(p, Endian.little);
            p += 4;
            if (Btype == 'i') {
              if (tag == 'CG') {
                for (var k = 0; k < limit; k++) {
                  var cigop = byteData.getInt32(p, Endian.little);
                  var lop = cigop >> 4;
                  var op = CIGAR_DECODER[cigop & 0xf];
                  value += '$lop$op';
                  p += 4;
                }
              } else {
                for (var k = 0; k < limit; k++) {
                  value += byteData.getInt32(p, Endian.little);
                  if (k + 1 < limit) {
                    value += ',';
                  }
                  p += 4;
                }
              }
            }
            if (Btype == 'I') {
              if (tag == 'CG') {
                for (var k = 0; k < limit; k++) {
                  var cigop = byteData.getUint32(p, Endian.little);
                  var lop = cigop >> 4;
                  var op = CIGAR_DECODER[cigop & 0xf];
                  value += '$lop$op';
                  p += 4;
                }
              } else {
                for (var k = 0; k < limit; k++) {
                  value += byteData.getUint32(p, Endian.little);
                  if (k + 1 < limit) {
                    value += ',';
                  }
                  p += 4;
                }
              }
            }
            if (Btype == 's') {
              for (var k = 0; k < limit; k++) {
                value += byteData.getInt16(p, Endian.little);
                if (k + 1 < limit) {
                  value += ',';
                }
                p += 2;
              }
            }
            if (Btype == 'S') {
              for (var k = 0; k < limit; k++) {
                value += byteData.getUint16(p, Endian.little);
                if (k + 1 < limit) {
                  value += ',';
                }
                p += 2;
              }
            }
            if (Btype == 'c') {
              for (var k = 0; k < limit; k++) {
                value += byteData.getInt8(p);
                if (k + 1 < limit) {
                  value += ',';
                }
                p += 1;
              }
            }
            if (Btype == 'C') {
              for (var k = 0; k < limit; k++) {
                value += byteData.getUint8(p);
                if (k + 1 < limit) {
                  value += ',';
                }
                p += 1;
              }
            }
            if (Btype == 'f') {
              for (var k = 0; k < limit; k++) {
                value += byteData.getFloat32(p, Endian.little);
                if (k + 1 < limit) {
                  value += ',';
                }
                p += 4;
              }
            }
            break;
          }
        default:
          print("Unknown BAM tag type '${type}', tags may be incompvare");
          value = null;
          p = blockEnd; // stop parsing tags
      }

      this._tagOffset = p;

      this._tagList.add(tag);
      if (lcTag == tagName) {
        return value;
      }

      this.data[lcTag] = value;
    }
    this._allTagsParsed = true;
    return null;
  }

  _parseAllTags() {
    this._parseTag('');
  }

  _parseCigar(String cigar) {
    return RegExp('\d+\D').allMatches(cigar).map((e) => [RegExp('\d+\D').stringMatch(e.group(0)), int.tryParse(e.group(0))]);

    // return (
    // //@ts-ignore
    // cigar
    //     .match(/\d+\D/g)
    // //@ts-ignore
    //     .map((String op) => [op.match(/\D/)[0].toUpperCase(), int.tryParse(op)])
    // );
  }

  /// @returns {boolean} true if the read is paired, regardless of whether both segments are mapped
  isPaired() {
    return !!(this.flags & Constants.BAM_FPAIRED);
  }

  /// @returns {boolean} true if the read is paired, and both segments are mapped */
  isProperlyPaired() {
    return !!(this.flags & Constants.BAM_FPROPER_PAIR);
  }

  /// @returns {boolean} true if the read itself is unmapped; conflictive with isProperlyPaired */
  isSegmentUnmapped() {
    return !!(this.flags & Constants.BAM_FUNMAP);
  }

  /// @returns {boolean} true if the read itself is unmapped; conflictive with isProperlyPaired */
  isMateUnmapped() {
    return !!(this.flags & Constants.BAM_FMUNMAP);
  }

  /// @returns {boolean} true if the read is mapped to the reverse strand */
  isReverseComplemented() {
    return !!(this.flags & Constants.BAM_FREVERSE);
  }

  /// @returns {boolean} true if the mate is mapped to the reverse strand */
  isMateReverseComplemented() {
    return !!(this.flags & Constants.BAM_FMREVERSE);
  }

  /// @returns {boolean} true if this is read number 1 in a pair */
  isRead1() {
    return !!(this.flags & Constants.BAM_FREAD1);
  }

  /// @returns {boolean} true if this is read number 2 in a pair */
  isRead2() {
    return !!(this.flags & Constants.BAM_FREAD2);
  }

  /// @returns {boolean} true if this is a secondary alignment */
  isSecondary() {
    return !!(this.flags & Constants.BAM_FSECONDARY);
  }

  /// @returns {boolean} true if this read has failed QC checks */
  isFailedQc() {
    return !!(this.flags & Constants.BAM_FQCFAIL);
  }

  /// @returns {boolean} true if the read is an optical or PCR duplicate */
  isDuplicate() {
    return !!(this.flags & Constants.BAM_FDUP);
  }

  /// @returns {boolean} true if this is a supplementary alignment */
  isSupplementary() {
    return !!(this.flags & Constants.BAM_FSUPPLEMENTARY);
  }

  cigar() {
    if (this.isSegmentUnmapped()) {
      return null;
    }

    int start = this.bytes['start'];
    var numCigarOps = this.get('_n_cigar_op');
    var p = start + 36 + this.get('_l_read_name');
    var seqLen = this.get('seq_length');
    var cigar = '';
    var lref = 0;

    // check for CG tag by inspecting whether the CIGAR field
    // contains a clip that consumes entire seqLen
    var cigop = byteData.getInt32(p, Endian.little);
    var lop = cigop >> 4;
    var op = CIGAR_DECODER[cigop & 0xf];
    if (op == 'S' && lop == seqLen) {
      // if there is a CG the second CIGAR field will
      // be a N tag the represents the length on ref
      p += 4;
      cigop = byteData.getInt32(p, Endian.little);
      lop = cigop >> 4;
      op = CIGAR_DECODER[cigop & 0xf];
      if (op != 'N') {
        print('CG tag with no N tag');
      }
      this.data['length_on_ref'] = lop;
      return this.get('CG');
    } else {
      for (var c = 0; c < numCigarOps; ++c) {
        cigop = byteData.getInt32(p, Endian.little);
        lop = cigop >> 4;
        op = CIGAR_DECODER[cigop & 0xf];
        cigar += '$lop$op';

        // soft clip, hard clip, and insertion don't count toward
        // the length on the reference
        if (op != 'H' && op != 'S' && op != 'I') {
          lref += lop;
        }

        p += 4;
      }

      this.data['length_on_ref'] = lref;
      return cigar;
    }
  }

  ByteData get byteData => _byteData;

  _flags() {}

  length_on_ref() {
    this.get('cigar'); // the length_on_ref is set as a
    // side effect of the CIGAR parsing
    return this.data['length_on_ref'];
  }

  _n_cigar_op() {
    return this.get('_flag_nc') & 0xffff;
  }

  _l_read_name() {
    return this.get('_bin_mq_nl') & 0xff;
  }

  /**
   * number of bytes in the sequence field
   */
  _seq_bytes() {
    return (this.get('seq_length') + 1) >> 1;
  }

  getReadBases() {
    return this._get_seq();
  }

  _get_seq() {
    var byteArray = this.bytes['byteArray'];
    var p = this.bytes['start'] + 36 + this.get('_l_read_name') + this.get('_n_cigar_op') * 4;
    var seqBytes = this.get('_seq_bytes');
    var len = this.get('seq_length');
    var buf = '';
    var i = 0;
    for (var j = 0; j < seqBytes; ++j) {
      var sb = byteArray[p + j];
      buf += String.fromCharCode(SEQRET_DECODER[(sb & 0xf0) >> 4]);
      i++;
      if (i < len) {
        buf += String.fromCharCode(SEQRET_DECODER[sb & 0x0f]);
        i++;
      }
    }
    return buf;
  }

  // adapted from igv.js
  getPairOrientation() {
    if (!this.isSegmentUnmapped() && !this.isMateUnmapped() && this._refID == this._next_refid()) {
      var s1 = this.isReverseComplemented() ? 'R' : 'F';
      var s2 = this.isMateReverseComplemented() ? 'R' : 'F';
      var o1 = ' ';
      var o2 = ' ';
      if (this.isRead1()) {
        o1 = '1';
        o2 = '2';
      } else if (this.isRead2()) {
        o1 = '2';
        o2 = '1';
      }

      var tmp = [];
      var isize = this.template_length();
      if (isize > 0) {
        tmp[0] = s1;
        tmp[1] = o1;
        tmp[2] = s2;
        tmp[3] = o2;
      } else {
        tmp[2] = s1;
        tmp[3] = o1;
        tmp[0] = s2;
        tmp[1] = o2;
      }
      return tmp.join('');
    }
    return null;
  }

  _bin_mq_nl() {
    return byteData.getInt32(this.bytes['start'] + 12, Endian.little);
  }

  _flag_nc() {
    return byteData.getInt32(this.bytes['start'] + 16, Endian.little);
  }

  seq_length() {
    return byteData.getInt32(this.bytes['start'] + 20, Endian.little);
  }

  _next_refid() {
    return byteData.getInt32(this.bytes['start'] + 24, Endian.little);
  }

  _next_pos() {
    return byteData.getInt32(this.bytes['start'] + 28, Endian.little);
  }

  template_length() {
    return byteData.getInt32(this.bytes['start'] + 32, Endian.little);
  }

  toJSON() {
    return {
      'data': data,
      'flags': flags,
    };
  }
}
